Skip to content

feature: deferred loading and requirement pruning #1199

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Draft
wants to merge 39 commits into
base: main
Choose a base branch
from

Conversation

leondz
Copy link
Collaborator

@leondz leondz commented May 7, 2025

Support construction-time loading of optional modules. Includes

  • many generators now using this pattern
  • pattern for loading modules at run-time and failing if absent
  • optional requirements moved to pyproject.toml options and pruned from requirements.txt
  • _load_deps and _clear_deps pattern, used in generator constructor and _load_client / _clear_client
  • tests to check that optional deps are in the right place and not in the wrong place

Todo / in scope:

  • skipping tests requiring absent optional imports
  • take a stance on whether to skip or stop on missing module, per plugin type (likely skip for probe, stop for main generator, beyond that tba)
  • Consider moving load/clear dep function defs up and out for all classes (where - garak._plugins? How - assign func member? create mixin?)
  • validation: exception throws correctly / doesn't throw if dependency present
    • cohere
    • langchain
    • litellm
    • mistral
    • nemoguardrails
    • nemollm
    • octo
    • ollama
    • optimum (huggingface.OptimumPipeline) - exception fires, pkg install seems borked
    • replicate

Not done:

  • load/clear deps for plugin types other than generators
  • gh actions for testing optional components
  • deeper validation

Out of scope:

  • handling of versioning outside of pyproject.toml

Resolves #101

@leondz leondz requested a review from jmartin-tech May 7, 2025 11:00
@leondz leondz added the architecture Architectural upgrades label May 7, 2025
@leondz leondz requested review from jmartin-tech and removed request for jmartin-tech May 7, 2025 11:04
@leondz leondz added this to the 0.11.0 milestone May 8, 2025
@leondz leondz self-assigned this May 8, 2025
@leondz leondz removed this from the release 0.11.0 milestone May 8, 2025
]
plugin_replicate = [
"replicate>=0.8.3",
]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider adding to allows for pip install garak[plugin_all]:

plugin_all = garak[
    plugin_cohere,
    plugin_langchain,
    plugin_llava,
    plugin_litellm,
    plugin_mistralai,
    plugin_nemoguardrails,
    plugin_nemollm,
    plugin_octoai,
    plugin_ollama,
    plugin_replicate,
]

There might be a better name to use for this group.

@leondz leondz requested a review from erickgalinkin as a code owner May 16, 2025 11:01
@leondz leondz marked this pull request as draft May 16, 2025 12:14
requirements.txt Outdated
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should we keep requirements.txt as a consolidated lists of all possible requirements and require usage of pyproject.toml to get a limited dependencies install?

Another option to me would be simply remove requirements.txt and require all installs be based on using pyproject.toml until we implement a lock file.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Right. After migrating the gh workflows to use pyproject.toml, requirements.txt becomes superfluous - I'm leaning toward the latter.

Removal does mean we lose a cross check in test_reqs, but that's OK. Could do with a test to ensure all packages (including optional) specity a version.

Copy link
Collaborator

@erickgalinkin erickgalinkin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Looks good to me overall! Just a handful of comments throughout.

Comment on lines +408 to +420
# check cache for optional imports
if category in PLUGIN_TYPES:
extra_dependency_names = PluginCache.instance()[category][full_plugin_name][
"extra_dependency_names"
]
if len(extra_dependency_names) > 0:
for dependency_module_name in extra_dependency_names:
for dependency_path in [ # support both plain names and also multi-point names e.g. langchain.llms
".".join(dependency_module_name.split(".")[: n + 1])
for n in range(dependency_module_name.count(".") + 1)
]:
if importlib.util.find_spec(dependency_path) is None:
_import_failed(dependency_path, full_plugin_name)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this really the best way to do this? Perhaps we just enforce lazy loading throughout instead? I'm not sure.

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I guess that is what we're doing. This is the hazard of doing code reviews linearly, I suppose.

Comment on lines +70 to +90
def _load_deps(self):
# load external dependencies. should be invoked at construction and
# in _client_load (if used)
for extra_dependency in self.extra_dependency_names:
extra_dep_name = extra_dependency.replace(".", "_").replace("-", "_")
if (
not hasattr(self, extra_dep_name)
or getattr(self, extra_dep_name) is None
):
setattr(
self,
extra_dep_name,
garak._plugins.load_optional_module(extra_dependency),
)

def _clear_deps(self):
# unload external dependencies from class. should be invoked before
# serialisation, esp. in _clear_client (if used)
for extra_dependency in self.extra_dependency_names:
extra_dep_name = extra_dependency.replace(".", "_")
setattr(self, extra_dep_name, None)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should this be in Configurable instead, since it can/should be used across all base classes?

@@ -158,19 +159,15 @@ class OptimumPipeline(Pipeline, HFCompatible):
generator_family_name = "NVIDIA Optimum Hugging Face 🤗 pipeline"
supports_multiple_generations = True
doc_uri = "https://huggingface.co/blog/optimum-nvidia"
extra_dependency_names = ["optimum-nvidia"]
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor note that has little to do with this PR: it does drive me a bit nuts that the dependency name and the import statement so often do not match.

Comment on lines 71 to 74
def _load_client(self):
self._load_deps()
if hasattr(self, "generator") and self.generator is not None:
return
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In the interest of DRYness, I notice this exact code repeated across a number of the HFCompatible classes; perhaps we should refactor into HFCompatible?

@@ -53,14 +50,7 @@ def __init__(self, name="", config_root=_config):

super().__init__(self.name, config_root=config_root)

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we missing a call to self._load_deps()?

@@ -105,6 +96,7 @@ class LiteLLMGenerator(Generator):
"skip_seq_start",
"skip_seq_end",
"stop",
"verbose",
)

def __init__(self, name: str = "", generations: int = 10, config_root=_config):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we missing a call to self._load_deps()?

@@ -37,6 +36,7 @@ class NeMoGenerator(Generator):

supports_multiple_generations = False
generator_family_name = "NeMo"
extra_dependency_names = ["nemollm"]

def __init__(self, name=None, config_root=_config):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we missing a call to self._load_deps()?

I keep asking this but I may be missing some logic that's inherited, so my question is very sincere.


def __init__(self, name="", config_root=_config):
from octoai.client import Client

self.name = name
self._load_config(config_root)
self.fullname = f"{self.generator_family_name} {self.name}"

super().__init__(self.name, config_root=config_root)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we missing a call to self._load_deps()?

@@ -28,17 +32,18 @@ class OllamaGenerator(Generator):
active = True
generator_family_name = "Ollama"
parallel_capable = False
extra_dependency_names = ["ollama"]

def __init__(self, name="", config_root=_config):
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Are we missing a call to self._load_deps()?

I'm going to stop asking every time and assume I've missed some inherited logic.

@@ -158,6 +158,7 @@ def __setstate__(self, d) -> object:
def _load_client(self):
# When extending `OpenAICompatible` this method is a likely location for target application specific
# customization and must populate self.generator with an openai api compliant object
self._load_deps()
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We don't seem to have added the openai dependency as extra_dependency_names. Is that deliberate?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
architecture Architectural upgrades
Projects
None yet
Development

Successfully merging this pull request may close these issues.

improved / consistent plugin lazy loading
3 participants